Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit bd4ea143939dd8fcb0fa7e44f9afd062df2652c1


Parents : 90ffd63
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-08T10:23:08-05:00

feat(WebSocketConnection): update reconnection logic to prevent thrashing during simultaneous connection attempts and improve handling of backoff retries

Changes

2 files changed, 313 insertions(+), 1 deletions(-)


Diff

diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.js
index 003d7968..a15d665e 100644
--- a/meshchatx/src/frontend/js/WebSocketConnection.js
+++ b/meshchatx/src/frontend/js/WebSocketConnection.js
@@ -121,10 +121,22 @@ class WebSocketConnection {
return;
}
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ // Don't tear down a connection that is already open, and don't
+ // abandon one that is already in flight (e.g. triggered again by a
+ // near-simultaneous focus/visibilitychange/online event) - doing so
+ // would thrash the socket and could delay recovery indefinitely.
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
+ // A new attempt is starting now, so any previously scheduled
+ // backoff retry (from an earlier close) is redundant - drop it so
+ // it can't later fire and interfere with this attempt.
+ if (this._reconnectTimeout != null) {
+ clearTimeout(this._reconnectTimeout);
+ this._reconnectTimeout = null;
+ }
+
if (this.ws) {
try {
this.ws.close();
@@ -210,6 +222,13 @@ class WebSocketConnection {
return;
}
+ // A connection attempt is already in flight (e.g. a previous
+ // foreground/network event just started one) - let it resolve on
+ // its own rather than tearing it down and starting another.
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
+ return;
+ }
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnect();
return;
@@ -224,6 +243,9 @@ class WebSocketConnection {
}
forceReconnect() {
+ if (!this.initialized || this.destroyed) {
+ return;
+ }
if (this.ws) {
this._isForcedReconnect = true;
try {

diff --git a/tests/frontend/WebSocketConnection.test.js b/tests/frontend/WebSocketConnection.test.js
index 178469ed..0f40477c 100644
--- a/tests/frontend/WebSocketConnection.test.js
+++ b/tests/frontend/WebSocketConnection.test.js
@@ -44,6 +44,30 @@ function makeWsImpl() {
};
}
+/** A WebSocket mock whose pings are never answered, to exercise pong-timeout handling. */
+function makeSilentWsImpl() {
+ const Base = makeWsImpl();
+ return class SilentWebSocket extends Base {
+ send() {
+ // swallow all sends (including pings) - never emit a pong
+ }
+ };
+}
+
+/** Minimal EventTarget-backed window mock so real focus/online/visibilitychange
+ * events can be dispatched and routed through addEventListener like a browser. */
+function makeWindowMock(extra = {}) {
+ const target = new EventTarget();
+ return {
+ api: {},
+ location: { origin: "http://127.0.0.1:5173" },
+ addEventListener: target.addEventListener.bind(target),
+ removeEventListener: target.removeEventListener.bind(target),
+ dispatchEvent: target.dispatchEvent.bind(target),
+ ...extra,
+ };
+}
+
describe("WebSocketConnection module", () => {
beforeEach(() => {
vi.resetModules();
@@ -164,4 +188,270 @@ describe("WebSocketConnection module", () => {
WebSocketConnection.destroy();
});
+
+ it("reconnect() does not thrash a connection attempt that is already in flight", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ // connect() has no internal awaits before constructing the socket,
+ // so checking readyState right after calling it (without awaiting
+ // the returned promise, which only settles after the queued "open"
+ // microtask) reliably catches it mid-connect.
+ WebSocketConnection.connect();
+ const connectingWs = WebSocketConnection.ws;
+ expect(connectingWs.readyState).toBe(MockWS.CONNECTING);
+
+ // calling reconnect() again while the first attempt hasn't resolved
+ // yet must not close/replace it.
+ WebSocketConnection.reconnect();
+ WebSocketConnection.reconnect();
+ expect(WebSocketConnection.ws).toBe(connectingWs);
+
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+ expect(WebSocketConnection.ws).toBe(connectingWs);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("ignores repeated foreground/network re-triggers while a reconnect is already in flight", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+ WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection.handleForegroundOrNetworkChange();
+
+ const connectingWs = WebSocketConnection.ws;
+ expect(connectingWs).not.toBe(firstWs);
+ expect(connectingWs.readyState).toBe(MockWS.CONNECTING);
+
+ // simulate visibilitychange, focus and online all firing again
+ // before the new socket has finished opening (very plausible when
+ // a mobile browser/webview comes back to the foreground).
+ WebSocketConnection.handleForegroundOrNetworkChange();
+ WebSocketConnection.handleForegroundOrNetworkChange();
+ WebSocketConnection.handleForegroundOrNetworkChange();
+
+ expect(WebSocketConnection.ws).toBe(connectingWs);
+
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+ expect(WebSocketConnection.ws).toBe(connectingWs);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("cancels a pending backoff retry when a reconnect happens out of turn", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+ // a real, unexpected disconnect - schedules a backoff retry
+ firstWs.close(1006, "abnormal");
+ await vi.waitUntil(() => WebSocketConnection._reconnectTimeout !== null);
+
+ // something else (e.g. a foreground event) triggers an immediate,
+ // out-of-turn reconnect before the backoff timer fires
+ WebSocketConnection.reconnect();
+ const outOfTurnWs = WebSocketConnection.ws;
+ expect(outOfTurnWs).not.toBe(firstWs);
+ expect(WebSocketConnection._reconnectTimeout).toBeNull();
+
+ await vi.waitUntil(() => outOfTurnWs.readyState === MockWS.OPEN);
+
+ // advance well past when the original backoff timer would have
+ // fired (base delay ~1s, capped well under this), but short of the
+ // next heartbeat/pong cycle so that unrelated timers don't muddy
+ // the assertion. It must have been cancelled and not fire a
+ // redundant second reconnect that would replace the healthy socket.
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(WebSocketConnection.ws).toBe(outOfTurnWs);
+ expect(WebSocketConnection.ws.readyState).toBe(MockWS.OPEN);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("forceReconnect performs a silent reconnect without emitting disconnected", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ const connected = vi.fn();
+ const disconnected = vi.fn();
+ WebSocketConnection.on("connected", connected);
+ WebSocketConnection.on("disconnected", disconnected);
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+ WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection.handleForegroundOrNetworkChange();
+
+ await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+
+ expect(disconnected).not.toHaveBeenCalled();
+ expect(connected).toHaveBeenCalledTimes(2);
+ expect(connected.mock.calls[1][0]).toEqual({ isReconnect: false });
+
+ WebSocketConnection.destroy();
+ });
+
+ it("handleForegroundOrNetworkChange and forceReconnect are no-ops before connect() or after destroy()", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ // never connected yet
+ expect(() => WebSocketConnection.handleForegroundOrNetworkChange()).not.toThrow();
+ expect(() => WebSocketConnection.forceReconnect()).not.toThrow();
+ expect(WebSocketConnection.ws).toBeNull();
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+ WebSocketConnection.destroy();
+
+ // destroyed
+ expect(() => WebSocketConnection.handleForegroundOrNetworkChange()).not.toThrow();
+ expect(() => WebSocketConnection.forceReconnect()).not.toThrow();
+ expect(WebSocketConnection.ws).toBeNull();
+ });
+
+ it("send() and ping() are safe no-ops when there is no open socket", async () => {
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ expect(WebSocketConnection.ws).toBeNull();
+ expect(() => WebSocketConnection.send("hello")).not.toThrow();
+ expect(() => WebSocketConnection.ping()).not.toThrow();
+ });
+
+ it("registers window event listeners only once across repeated connect() calls", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const addEventListenerSpy = vi.fn();
+ global.window = {
+ api: {},
+ location: { origin: "http://127.0.0.1:5173" },
+ addEventListener: addEventListenerSpy,
+ removeEventListener: vi.fn(),
+ };
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+ const countAfterFirst = addEventListenerSpy.mock.calls.length;
+ expect(countAfterFirst).toBeGreaterThan(0);
+
+ WebSocketConnection.destroy();
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ expect(addEventListenerSpy.mock.calls.length).toBe(countAfterFirst);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("only reacts to visibilitychange when the document becomes visible", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+ global.window = makeWindowMock();
+ global.document = { visibilityState: "hidden" };
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+ WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+
+ // still hidden - must not trigger a reconnect
+ global.window.dispatchEvent(new Event("visibilitychange"));
+ expect(WebSocketConnection.ws).toBe(firstWs);
+ expect(firstWs.readyState).toBe(MockWS.OPEN);
+
+ // becoming visible should run the stale-connection check
+ global.document.visibilityState = "visible";
+ global.window.dispatchEvent(new Event("visibilitychange"));
+
+ await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+ expect(WebSocketConnection.ws).not.toBe(firstWs);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("reacts to real focus and online DOM events", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+ global.window = makeWindowMock();
+ global.document = { visibilityState: "visible" };
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+ const sendSpy = vi.spyOn(firstWs, "send");
+
+ WebSocketConnection._lastReceivedTime = Date.now();
+ global.window.dispatchEvent(new Event("focus"));
+ expect(sendSpy).toHaveBeenCalled();
+ expect(WebSocketConnection.ws).toBe(firstWs);
+
+ WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ global.window.dispatchEvent(new Event("online"));
+
+ await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+ expect(WebSocketConnection.ws).not.toBe(firstWs);
+
+ WebSocketConnection.destroy();
+ });
+
+ it("closes and schedules a reconnect when a heartbeat ping goes unanswered", async () => {
+ const SilentWS = makeSilentWsImpl();
+ global.WebSocket = SilentWS;
+
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ const disconnected = vi.fn();
+ WebSocketConnection.on("disconnected", disconnected);
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === SilentWS.OPEN);
+
+ const firstWs = WebSocketConnection.ws;
+
+ // the very first heartbeat ping is sent on open but never answered;
+ // once the pong timeout elapses the socket should be force-closed.
+ await vi.advanceTimersByTimeAsync(12000 + 500);
+
+ await vi.waitUntil(() => disconnected.mock.calls.length >= 1);
+ expect(firstWs.readyState).toBe(SilentWS.CLOSED);
+
+ WebSocketConnection.destroy();
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────